Skip to content

feat: On-chain milestone arbitration — staked jurors resolve disputed rejections - #1088

Merged
Anuoluwapo25 merged 4 commits into
bakeronchain:mainfrom
EmmanuelOchaje:feat/issue-1082-milestone-arbitration
Aug 21, 2026
Merged

feat: On-chain milestone arbitration — staked jurors resolve disputed rejections#1088
Anuoluwapo25 merged 4 commits into
bakeronchain:mainfrom
EmmanuelOchaje:feat/issue-1082-milestone-arbitration

Conversation

@EmmanuelOchaje

@EmmanuelOchaje EmmanuelOchaje commented Aug 20, 2026

Copy link
Copy Markdown

Summary

Closes #1082.

Adds an on-chain arbitration path for disputed milestone rejections. Today, milestone-appeal.controller.ts puts the final call in the hands of a single off-chain admin, and milestone_escrow::release_tranche only ever moves on that admin's signature — the most centralized point in a protocol whose pitch is "no gatekeepers." This PR gives a rejected scholar a credibly neutral alternative: escalate to a panel of LRN-staking jurors who vote commit-reveal, with the escrow release bound to their outcome and stakes slashed for voting against it (or for staying silent).

What's here

Contract — contracts/milestone_arbitration/

  • join_panel / leave_panel — stake LRN to become eligible; leaving requires no active assignments so a juror can't dodge a slash mid-dispute.
  • open_dispute — scholar-only (require_auth), stakes 500 LRN, draws a 5-juror panel immediately, weighted by stake and seeded from the ledger sequence/timestamp at draw time (not caller-controlled).
  • commit_vote / reveal_vote — commit-reveal; a reveal that doesn't match its commitment is rejected, never silently dropped.
  • resolve — callable by anyone once the panel has fully revealed or the reveal deadline passes. Tallies votes, slashes the minority and every non-revealer, and redistributes the slashed pool between the majority jurors and the winning party.
  • Every phase (open window, commit, reveal) is deadline-bounded, and a quorum-failure fallback is fully specified: below 3-of-5 revealed, the rejection stands, the scholar is refunded in full, revealers keep their stake, and non-revealers are slashed to the treasury. Ties among revealed votes also favor the status quo.
  • 31 tests: full happy path (release + uphold), minority slashing, non-revealer slashing, quorum-failure fallback, double-vote/double-join rejected, dispute-outside-window rejected, scholar-only auth, and a test demonstrating panel selection changes with ledger state rather than caller input.

Escrow integration — contracts/milestone_escrow/

  • New release_tranche_via_arbitration(proposal_id, dispute_id): permissionless, but only succeeds if the arbitration contract's own get_release_outcome independently confirms a favorable, quorum-met outcome for that proposal — and each dispute_id can only ever authorize one release.
  • The arbitration contract address is bootstrapped once via set_arbitration_contract, then changeable only through queue_arbitration_change / execute_arbitration_change, a 48h timelock mirroring upgrade_timelock_vault's pattern — never a bare admin call.
  • 6 new tests covering the bootstrap-once rule, the timelock, and both favorable/unfavorable arbitration outcomes (including a local mock arbitration contract for the cross-contract call).

Backend

  • Migration 031_milestone_arbitration.sql: disputes, dispute_jurors, dispute_votes, a pending_dispute_evidence bridge table, and new dispute_* notification-preference columns.
  • event-indexer.service.ts maps DisputeOpened / VoteCommitted / VoteRevealed / DisputeResolved into the read model and fires notifications to the scholar and every panel member at each phase change (so a commit-reveal scheme doesn't quietly slash someone who was never told).
  • GET /api/disputes, GET /api/disputes/:id, GET /api/disputes/juror/:address, GET /api/disputes/milestone/:proposalId/:milestoneId, POST /api/disputes/pending-evidence.
  • Evidence goes to IPFS through the existing pinata.service.ts upload endpoint; only its hash ever reaches the chain.

Frontend

  • An "Escalate to arbitration" control on rejected/appeal-denied milestones (ScholarMilestones.tsx), stating the 500 LRN stake cost before signing.
  • /disputes — a juror console: stake to join the pool, see panel assignments, commit and reveal with a persisted salt (lost salt = lost vote, so it's saved locally between the two steps).
  • /disputes/:id — a public detail page with phase, deadlines, vote tallies, and the resolution transaction link.
  • Every signing step shows pending/success/failure via the existing toast pattern.

Economics — the questions the issue asked to have answered

What stops a whale from dominating a panel? Selection weight per juror is capped (10,000 LRN), and the draw is without replacement, so one address can only ever hold one of the five seats regardless of stake size. Buying more stake raises the odds of being drawn, never the number of seats.

What stops a scholar from disputing indefinitely? Each rejected milestone can be disputed exactly once, disputing costs a real 500 LRN that's forfeited outright on a loss, and disputes must be opened within 7 days of the rejection. Every loss is a real, non-recoverable cost.

Parameters (documented with rationale in the contract's module doc comment): panel size 5, quorum 3, 500 LRN scholar stake, 1,000 LRN minimum juror stake, 7-day dispute window, 3-day commit window, 2-day reveal window, 50% minority slash, 100% non-participation slash, 70/30 split of the slashed pool between majority jurors and the winning party. These are starting points, not settled policy — happy to discuss before merge.

A known, honestly-scoped gap

The underlying rejection is still recorded off-chain (milestone-appeal.controller.ts), so open_dispute has no on-chain rejection record to check against — rejected_at is a caller-supplied timestamp that only rate-limits staleness, documented as such in the contract's doc comments. Closing this fully means the admin rejection path publishing something on-chain the arbitration contract can verify, which felt like a separate, larger change and is flagged as follow-up rather than smuggled in half-built.

Test plan

  • cargo test --workspace — all green, including 31 new arbitration tests and 6 new escrow-integration tests
  • cargo fmt / cargo clippy --workspace --all-targets — clean on every file this PR touches (two pre-existing warnings elsewhere in the tree are untouched by this change)
  • server: npm run build (tsc) — clean
  • Frontend: npx tsc --noEmit, yarn lint, yarn build — all clean on every file this PR touches
  • Not run: a live devnet deploy exercising the full flow end-to-end (no local Stellar node in this environment) — contract logic is covered by unit tests instead

…voting

Adds a milestone_arbitration Soroban contract so a rejected scholar can
escalate to a panel of LRN-staking jurors instead of the single off-chain
admin decision milestone-appeal.controller.ts currently makes final.

- New milestone_arbitration contract: juror staking, weighted panel
  selection seeded from ledger data, commit-reveal voting, slashing, and a
  defined quorum-failure fallback. Full unit test coverage.
- milestone_escrow gains an arbitration-authorized release path alongside
  the existing admin path; the arbitration contract address is changeable
  only through a queue/execute timelock, never a bare admin call.
- Backend: migration for disputes/dispute_jurors/dispute_votes, indexer
  wiring for the dispute lifecycle events, REST endpoints, and
  notifications at each phase change.
- Frontend: an escalate control on rejected milestones, a juror console
  (join pool, commit/reveal with locally-persisted salts), and a public
  dispute detail page.
@EmmanuelOchaje
EmmanuelOchaje force-pushed the feat/issue-1082-milestone-arbitration branch from f3865c0 to 88c6c53 Compare August 20, 2026 16:43
Anuoluwapo25 and others added 3 commits August 21, 2026 12:00
The multilingual-content-translation PR merged into main first and
claimed 031_translation_content_schema.sql. Renumbering the arbitration
migration to 032 avoids stacking a third duplicate migration number on
top of the two that already exist in this tree (027, 030).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: On-chain milestone arbitration — staked jurors resolve disputed rejections

2 participants